长连接保活、断线重连、休眠唤醒后的状态恢复怎么设计?
考察点
- agent 长连接的完整健壮性设计
- 心跳、重连、消息可靠性三板斧
参考答案
1. 整体架构
长连接放主进程(不放渲染进程):
- 渲染进程随时可能关闭/崩溃,主进程常驻
- 窗口全关后连接仍在,agent 才能收推送
- 收到消息后由主进程路由给需要的窗口
2. 心跳保活
class Connection {
constructor(url) {
this.url = url;
this.heartbeatInterval = 25_000; // 25s 心跳(小于运营商 NAT 超时)
this.heartbeatTimeout = 10_000; // 10s 未回 pong 判死
}
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
// 应用层 ping(比 ws 协议层 ping 更可靠,能穿透中间代理)
this.send({ type: 'ping', ts: Date.now() });
// 启动 pong 超时检测
this.pongTimer = setTimeout(() => {
this.logger.warn('心跳超时,判定连接死亡');
this.ws.terminate(); // 强制断开,触发重连
}, this.heartbeatTimeout);
}, this.heartbeatInterval);
}
onPong() {
clearTimeout(this.pongTimer); // 收到 pong,取消超时
}
}
要点:
- 应用层心跳优先于 WebSocket 协议层 ping/pong(协议层 ping 无 JS API 且部分代理不转发)
- 心跳间隔经验值 25-60s:太长会被 NAT/防火墙静默断连,太短费电耗流
- 必须有超时判死:只发不收检测,"僵尸连接"(半开状态)是 agent 假在线的头号原因
3. 断线重连
reconnect() {
// 指数退避 + 抖动,防服务端被重连风暴打挂
const baseDelay = Math.min(1000 * 2 ** this.retryCount, 60_000);
const jitter = Math.random() * 1000;
const delay = baseDelay + jitter;
this.retryCount++;
setTimeout(() => this.connect(), delay);
}
onOpen() {
this.retryCount = 0; // 连上后重置退避
this.resync(); // 状态同步
}
触发重连的时机(多源监听):
ws.on('close')/ws.on('error')- 心跳超时主动 terminate
- powerMonitor resume(系统唤醒,连接大概率已死,立即重连不等心跳)
- 网络状态变化(
online事件 / 周期探测)
4. 休眠唤醒的处理(桌面端特有)
powerMonitor.on('suspend', () => {
// 休眠前主动断开,标记离线,停止心跳
this.gracefulClose('system-suspend');
});
powerMonitor.on('resume', () => {
// 唤醒立即重连(此时网络可能还没就绪,退避重试兜底)
this.reconnect();
});
不处理 suspend 的恶果:休眠期间定时器冻结,唤醒后代码以为连接还活着(心跳 timer 一恢复发现超时很久才判死),中间几分钟消息全丢。
5. 消息可靠性(连接会断,消息不能丢)
(1)离线消息队列(发送方向)
class MessageQueue {
constructor() {
this.pending = []; // 待确认消息
}
send(msg) {
msg.id = uuid();
this.pending.push(msg);
this.tryFlush();
}
tryFlush() {
if (!this.connected) return;
for (const msg of this.pending) {
this.ws.send(JSON.stringify(msg));
}
}
onAck(msgId) {
// 服务端 ack 后才移除,否则重连后重发
this.pending = this.pending.filter((m) => m.id !== msgId);
}
onReconnect() {
this.tryFlush(); // 重连后重发所有未 ack 消息
}
}
(2)接收方向:序号 + 拉取补偿
- 服务端消息带递增 seq,客户端记录 lastSeq
- 重连后上报 lastSeq,服务端补发缺口(类似 Kafka offset)
(3)去重:重发/补发会导致重复消息,消费端按 msg.id 幂等去重。
6. 状态机视角
disconnected → connecting → connected → (heartbeat ok)
↑ ↓ ↓
└─── retry ── fail close/timeout ──→ reconnecting
明确的状态机 + 状态变更事件推给 UI(托盘图标变灰、窗口显示"连接中"),用户可感知连接健康度。
7. 常见坑
- 只监听 close 不做心跳超时:半开连接永远不触发 close
- 重连无退避:服务端故障恢复瞬间被海量客户端同时重连打挂(雷霆万钧问题),抖动是关键
- 多实例多连接:单实例锁保证只有一个连接(服务端按设备踢重连,双连接会互踢)
- token 过期:重连时先刷新鉴权 token,401 类错误走刷新流程而不是无限重连
面试话术
长连接四件套:应用层心跳 + 超时判死(治僵尸连接);指数退避加抖动重连(治重连风暴);suspend/resume 主动断开与立即重连(治休眠假在线);离线队列 + ack + seq 补偿 + 幂等去重(治消息丢失与重复)。连接放主进程常驻,状态机变化推给 UI 可感知。
常见追问
- 为什么不用 TCP 而选 WebSocket? WS 穿透性好(80/443 过防火墙代理)、协议成熟;TCP 更省但企业网络环境易被拦。
- 服务端主动踢重连怎么设计? 同设备新连接建立时踢掉旧连接(防多端/僵尸),客户端被踢走正常重连流程。